CrewMembersTab.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  1. 'use client';
  2. import { useState, useEffect, useCallback, useRef } from 'react';
  3. import * as signalR from '@microsoft/signalr';
  4. import { fetchApi } from '@/lib/utils/client';
  5. import { useStudioContext } from '@/app/studio/context';
  6. import { useSignalRContext } from '@/contexts/signalrProvider';
  7. import type { CrewMemberItem, CrewMemberListResponse, SearchResultItem, SearchMemberResponse } from '@/types/response/crew/member';
  8. import { Button } from '@/components/ui/button';
  9. import { Input } from '@/components/ui/input';
  10. import { Label } from '@/components/ui/label';
  11. import { Checkbox } from '@/components/ui/checkbox';
  12. import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
  13. import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
  14. import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip';
  15. import { Info } from 'lucide-react';
  16. function RequiredLabel({ htmlFor, children }: { htmlFor: string; children: React.ReactNode }) {
  17. return <Label htmlFor={htmlFor}><span className="text-destructive mr-0.5">*</span>{children}</Label>;
  18. }
  19. const EMPTY_MEMBER_FORM = {
  20. nickname: '',
  21. role: '',
  22. sortOrder: 0,
  23. isActive: true
  24. };
  25. type Props = {
  26. crewID: number;
  27. };
  28. export default function CrewMembersTab({ crewID }: Props)
  29. {
  30. const { channelID } = useStudioContext();
  31. const { appConnection, appConnected } = useSignalRContext();
  32. const [members, setMembers] = useState<CrewMemberItem[]>([]);
  33. const [loading, setLoading] = useState(true);
  34. // 초대 코드
  35. const [inviteCode, setInviteCode] = useState<string|null>(null);
  36. const [generatingCode, setGeneratingCode] = useState(false);
  37. // 회원 검색
  38. const [searchQuery, setSearchQuery] = useState('');
  39. const [searchResults, setSearchResults] = useState<SearchResultItem[]>([]);
  40. const [showSearch, setShowSearch] = useState(false);
  41. const searchTimer = useRef<ReturnType<typeof setTimeout>|null>(null);
  42. // 크루원 편집 모달
  43. const [editModal, setEditModal] = useState<{ open: boolean; member: CrewMemberItem|null }>({ open: false, member: null });
  44. const [editForm, setEditForm] = useState(EMPTY_MEMBER_FORM);
  45. const [saving, setSaving] = useState(false);
  46. const fetchMembers = useCallback(async () => {
  47. try {
  48. const res = await fetchApi<CrewMemberListResponse>(`/api/studio/crew/member/list/${crewID}`);
  49. setMembers(res.data?.list ?? []);
  50. } catch {
  51. } finally {
  52. setLoading(false);
  53. }
  54. }, [crewID]);
  55. useEffect(() => { fetchMembers(); }, [fetchMembers]);
  56. // SignalR 멤버 변경 이벤트 수신 — 운영자 그룹(crew:{crewID}) 구독 + 200ms debounce refetch.
  57. // 다중 이벤트(예: 일괄 작업) 시 한 번만 refetch 하도록 합침.
  58. useEffect(() => {
  59. if (!appConnection || !appConnected) {
  60. return;
  61. }
  62. if (appConnection.state !== signalR.HubConnectionState.Connected) {
  63. return;
  64. }
  65. let refetchTimer: ReturnType<typeof setTimeout>|null = null;
  66. const scheduleRefetch = () => {
  67. if (refetchTimer) {
  68. clearTimeout(refetchTimer);
  69. }
  70. refetchTimer = setTimeout(() => {
  71. fetchMembers();
  72. }, 200);
  73. };
  74. const handler = (_payload: unknown) => {
  75. scheduleRefetch();
  76. };
  77. appConnection.on('ReceiveCrewMemberChange', handler);
  78. appConnection.invoke('JoinCrew', crewID).catch(err => {
  79. console.warn('[crew] JoinCrew failed:', err);
  80. });
  81. return () => {
  82. if (refetchTimer) {
  83. clearTimeout(refetchTimer);
  84. }
  85. appConnection.off('ReceiveCrewMemberChange', handler);
  86. if (appConnection.state === signalR.HubConnectionState.Connected) {
  87. appConnection.invoke('LeaveCrew', crewID).catch(() => {});
  88. }
  89. };
  90. }, [appConnection, appConnected, crewID, fetchMembers]);
  91. // SignalR presence 구독 — 멤버 목록 변경 시 재구독, 이벤트 수신 시 isOnline 갱신.
  92. // members 가 빈 배열일 때는 구독할 대상이 없어 early return — 첫 fetchMembers 완료 후 의존성 변화로 재실행되어 정상 구독.
  93. const memberIDsKey = members.map(m => m.memberID).join(',');
  94. useEffect(() => {
  95. if (!appConnection || !appConnected) {
  96. return;
  97. }
  98. if (appConnection.state !== signalR.HubConnectionState.Connected) {
  99. return;
  100. }
  101. const memberIDs = Array.from(new Set(members.map(m => m.memberID))).filter(id => id > 0);
  102. if (memberIDs.length === 0) {
  103. return;
  104. }
  105. const handler = (memberID: number, isOnline: boolean) => {
  106. setMembers(prev => prev.map(m => m.memberID === memberID ? { ...m, isOnline } : m));
  107. };
  108. appConnection.on('ReceivePresenceChange', handler);
  109. appConnection.invoke('SubscribePresence', memberIDs).catch(err => {
  110. console.warn('[presence] SubscribePresence failed:', err);
  111. });
  112. return () => {
  113. appConnection.off('ReceivePresenceChange', handler);
  114. if (appConnection.state === signalR.HubConnectionState.Connected) {
  115. appConnection.invoke('UnsubscribePresence', memberIDs).catch(() => {});
  116. }
  117. };
  118. // memberIDsKey 가 동일하면 effect 재실행 안됨 (불필요한 sub/unsub 방지).
  119. // members 자체를 의존성에 넣지 않는 이유: members 가 같은 ID 집합이어도 객체 reference 가 바뀌면 매번 재실행되므로.
  120. // eslint-disable-next-line react-hooks/exhaustive-deps
  121. }, [appConnection, appConnected, memberIDsKey]);
  122. // 회원 검색 (디바운스)
  123. useEffect(() => {
  124. if (searchTimer.current) {
  125. clearTimeout(searchTimer.current);
  126. }
  127. if (searchQuery.trim().length < 2) {
  128. setSearchResults([]);
  129. return;
  130. }
  131. searchTimer.current = setTimeout(async () => {
  132. try {
  133. const res = await fetchApi<SearchMemberResponse>(
  134. `/api/studio/crew/member/search?channelID=${channelID}&q=${encodeURIComponent(searchQuery)}&crewID=${crewID}`
  135. );
  136. setSearchResults(res.data?.list ?? []);
  137. } catch {}
  138. }, 300);
  139. return () => {
  140. if (searchTimer.current) {
  141. clearTimeout(searchTimer.current);
  142. }
  143. };
  144. }, [searchQuery, channelID, crewID]);
  145. const handleAddMember = async (item: SearchResultItem) => {
  146. try {
  147. await fetchApi('/api/studio/crew/member/add', {
  148. method: 'POST',
  149. body: {
  150. crewID,
  151. targetMemberID: item.memberID,
  152. nickname: item.channelName ?? item.name ?? item.email,
  153. sortOrder: members.length + 1
  154. }
  155. });
  156. setSearchQuery('');
  157. setSearchResults([]);
  158. setShowSearch(false);
  159. fetchMembers();
  160. } catch (err: unknown) {
  161. alert(err instanceof Error ? err.message : '추가에 실패했습니다.');
  162. }
  163. };
  164. const handleRemove = async (memberID: number) => {
  165. if (!confirm('이 크루원을 삭제하시겠습니까?')) {
  166. return;
  167. }
  168. try {
  169. await fetchApi(`/api/studio/crew/member/${memberID}`, { method: 'DELETE' });
  170. fetchMembers();
  171. } catch (err: unknown) {
  172. alert(err instanceof Error ? err.message : '삭제에 실패했습니다.');
  173. }
  174. };
  175. const openEdit = (member: CrewMemberItem) => {
  176. setEditForm({ nickname: member.nickname, role: member.role ?? '', sortOrder: member.sortOrder, isActive: member.isActive });
  177. setEditModal({ open: true, member });
  178. };
  179. const handleUpdate = async () => {
  180. if (!editModal.member) {
  181. return;
  182. }
  183. setSaving(true);
  184. try {
  185. await fetchApi(`/api/studio/crew/member/${editModal.member.id}`, {
  186. method: 'PUT',
  187. body: {
  188. crewMemberID: editModal.member.id,
  189. nickname: editForm.nickname,
  190. role: editForm.role || null,
  191. sortOrder: editForm.sortOrder,
  192. isActive: editForm.isActive
  193. }
  194. });
  195. setEditModal({ open: false, member: null });
  196. fetchMembers();
  197. } catch (err: unknown) {
  198. alert(err instanceof Error ? err.message : '수정에 실패했습니다.');
  199. } finally {
  200. setSaving(false);
  201. }
  202. };
  203. const handleGenerateCode = async () => {
  204. setGeneratingCode(true);
  205. try {
  206. const res = await fetchApi<{ inviteCode: string }>('/api/studio/crew/invite/generate', {
  207. method: 'POST',
  208. body: { crewID }
  209. });
  210. setInviteCode(res.data?.inviteCode ?? null);
  211. } catch (err: unknown) {
  212. alert(err instanceof Error ? err.message : '코드 생성에 실패했습니다.');
  213. } finally {
  214. setGeneratingCode(false);
  215. }
  216. };
  217. const copyCode = () => {
  218. if (inviteCode) {
  219. navigator.clipboard.writeText(inviteCode);
  220. alert('복사되었습니다.');
  221. }
  222. };
  223. if (loading) return <p className="studio-page__empty">준비 중...</p>;
  224. return (
  225. <>
  226. {/* 초대 코드 */}
  227. <fieldset className="crew-invite">
  228. <legend className="crew-invite__legend">
  229. <span>초대 코드</span>
  230. <TooltipProvider delay={0}>
  231. <Tooltip>
  232. <TooltipTrigger
  233. render={
  234. <button type="button" className="crew-invite__help" aria-label="초대 코드 안내">
  235. <Info size={14} aria-hidden="true" />
  236. </button>
  237. }
  238. />
  239. <TooltipContent side="top" align="start">
  240. 이 코드를 입력한 회원은 크루 방송 합류에 동의하는 것으로 처리됩니다.<br />
  241. 입력 페이지: /crew/join
  242. </TooltipContent>
  243. </Tooltip>
  244. </TooltipProvider>
  245. </legend>
  246. <div className="crew-invite__body">
  247. <Input
  248. readOnly
  249. value={inviteCode ?? ''}
  250. placeholder="코드를 생성해 주세요"
  251. className="crew-invite__input"
  252. />
  253. <div className="crew-invite__actions">
  254. {inviteCode ? (
  255. <>
  256. <Button type="button" variant="outline" onClick={copyCode}>복사</Button>
  257. <Button type="button" variant="outline" onClick={handleGenerateCode} disabled={generatingCode}>
  258. {generatingCode ? '생성 중...' : '재생성'}
  259. </Button>
  260. </>
  261. ) : (
  262. <Button type="button" onClick={handleGenerateCode} disabled={generatingCode}>
  263. {generatingCode ? '생성 중...' : '코드 생성'}
  264. </Button>
  265. )}
  266. </div>
  267. </div>
  268. </fieldset>
  269. {/* 크루원 관리 */}
  270. <div className="crew-members">
  271. <div className="crew-members__toolbar pb-3">
  272. <h2 className="crew-members__subtitle">크루원 ({members.length}명)</h2>
  273. <Button size="sm" onClick={() => setShowSearch(!showSearch)}>
  274. {showSearch ? '닫기' : '+ 크루원 추가'}
  275. </Button>
  276. </div>
  277. {showSearch && (
  278. <div className="member-search mb-4">
  279. <Input className="member-search__input" placeholder="채널명, 핸들, 이메일로 검색 (2자 이상)" value={searchQuery} onChange={e => setSearchQuery(e.target.value)} autoComplete='off'/>
  280. {searchResults.length > 0 && (
  281. <div className="member-search__results">
  282. {searchResults.map(item => (
  283. <button type="button" key={item.memberID} className="member-search__item" onClick={() => handleAddMember(item)}>
  284. {item.channelThumb ? <img src={item.channelThumb} alt="" className="member-search__thumb" /> : <div className="member-search__thumb" />}
  285. <div className="member-search__info">
  286. <div className="member-search__name">{item.channelName ?? item.name ?? '(이름 없음)'}</div>
  287. <div className="member-search__email">{item.email}</div>
  288. {item.channelHandle && (
  289. <div className="member-search__handle">@{item.channelHandle}</div>
  290. )}
  291. </div>
  292. </button>
  293. ))}
  294. </div>
  295. )}
  296. </div>
  297. )}
  298. <div className="studio-page__table-wrap">
  299. <table className="studio-page__table">
  300. <thead>
  301. <tr>
  302. <th>순서</th>
  303. <th>별명</th>
  304. <th>역할</th>
  305. <th>채널</th>
  306. <th>접속</th>
  307. <th>상태</th>
  308. <th>가입일</th>
  309. <th>작업</th>
  310. </tr>
  311. </thead>
  312. <tbody>
  313. {members.length === 0 ? (
  314. <tr><td colSpan={8} className="studio-page__empty">등록된 크루원이 없습니다.</td></tr>
  315. ) : members.map(m => {
  316. const isAccepted = m.inviteStatus === 'Accepted';
  317. const statusLabel = m.inviteStatus === 'Accepted'
  318. ? (m.isActive ? '활성' : '비활성')
  319. : m.inviteStatus === 'Pending' ? '초대 중'
  320. : m.inviteStatus === 'Declined' ? '거절됨'
  321. : '만료';
  322. const statusModifier = m.inviteStatus === 'Accepted'
  323. ? (m.isActive ? 'active' : 'inactive')
  324. : m.inviteStatus === 'Pending' ? 'pending'
  325. : 'expired';
  326. return (
  327. <tr key={m.id}>
  328. <td>{m.sortOrder}</td>
  329. <td>
  330. <Avatar className="member-row__thumb">
  331. <AvatarImage src={(m.channelThumb ?? m.thumb) || undefined} alt={m.nickname} />
  332. <AvatarFallback className="member-row__thumb-fallback p-1">
  333. <img src="/icons/layout/avatar.svg" alt={m.nickname} />
  334. </AvatarFallback>
  335. </Avatar>
  336. {m.nickname}
  337. </td>
  338. <td>{m.role ?? '-'}</td>
  339. <td>{m.channelName ?? '-'}</td>
  340. <td>
  341. <span
  342. className={`member-row__online-dot${m.isOnline ? ' member-row__online-dot--on' : ''}`}
  343. title={m.isOnline ? '온라인' : '오프라인'}
  344. aria-label={m.isOnline ? '온라인' : '오프라인'}
  345. />
  346. </td>
  347. <td><span className={`studio-page__badge studio-page__badge--${statusModifier}`}>{statusLabel}</span></td>
  348. <td>{new Date(m.joinedAt).toLocaleDateString('ko-KR')}</td>
  349. <td>
  350. <div className="studio-page__actions">
  351. {isAccepted ? (
  352. <>
  353. <Button variant="outline" size="sm" onClick={() => openEdit(m)}>수정</Button>
  354. <Button variant="destructive" size="sm" onClick={() => handleRemove(m.id)}>삭제</Button>
  355. </>
  356. ) : (
  357. <span className="text-xs text-muted-foreground">{m.inviteStatus === 'Pending' ? '응답 대기' : '-'}</span>
  358. )}
  359. </div>
  360. </td>
  361. </tr>
  362. );
  363. })}
  364. </tbody>
  365. </table>
  366. </div>
  367. </div>
  368. {/* 크루원 편집 모달 */}
  369. <Dialog open={editModal.open} onOpenChange={open => { if (!open) setEditModal({ open: false, member: null }); }}>
  370. <DialogContent>
  371. <DialogHeader>
  372. <DialogTitle>크루원 수정</DialogTitle>
  373. </DialogHeader>
  374. <div className="space-y-4">
  375. <div className="space-y-2">
  376. <RequiredLabel htmlFor="member-nickname">별명</RequiredLabel>
  377. <Input id="member-nickname" value={editForm.nickname} onChange={e => setEditForm(f => ({ ...f, nickname: e.target.value }))} />
  378. </div>
  379. <div className="space-y-2">
  380. <Label htmlFor="member-role">역할</Label>
  381. <Input id="member-role" value={editForm.role} onChange={e => setEditForm(f => ({ ...f, role: e.target.value }))} placeholder="예: 게이머, MC" />
  382. </div>
  383. <div className="space-y-2">
  384. <Label htmlFor="member-order">순서</Label>
  385. <Input id="member-order" type="number" min={0} value={editForm.sortOrder} onChange={e => setEditForm(f => ({ ...f, sortOrder: Number(e.target.value) }))} />
  386. </div>
  387. <div className="flex items-center gap-2">
  388. <Checkbox id="member-active" checked={editForm.isActive} onCheckedChange={v => setEditForm(f => ({ ...f, isActive: !!v }))} />
  389. <Label htmlFor="member-active">활성화</Label>
  390. </div>
  391. </div>
  392. <DialogFooter className="flex flex-row justify-center gap-2 sm:justify-center">
  393. <Button variant="outline" className="flex-1 sm:flex-none" onClick={() => setEditModal({ open: false, member: null })}>취소</Button>
  394. <Button className="flex-1 sm:flex-none" onClick={handleUpdate} disabled={saving}>{saving ? '저장 중...' : '저장'}</Button>
  395. </DialogFooter>
  396. </DialogContent>
  397. </Dialog>
  398. </>
  399. );
  400. }